summaryrefslogtreecommitdiff
path: root/app/[lng]/admin/temp-db-viewer/page.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'app/[lng]/admin/temp-db-viewer/page.tsx')
-rw-r--r--app/[lng]/admin/temp-db-viewer/page.tsx141
1 files changed, 141 insertions, 0 deletions
diff --git a/app/[lng]/admin/temp-db-viewer/page.tsx b/app/[lng]/admin/temp-db-viewer/page.tsx
new file mode 100644
index 00000000..6692e63e
--- /dev/null
+++ b/app/[lng]/admin/temp-db-viewer/page.tsx
@@ -0,0 +1,141 @@
+"use client";
+
+import * as React from "react";
+import { useActionState, useState } from "react";
+import { executeSqlAction, type QueryResultState } from "./actions";
+import { Textarea } from "@/components/ui/textarea";
+import { Button } from "@/components/ui/button";
+import { toast } from "sonner";
+
+// CSV 변환 유틸
+function convertToCSV(columns: string[], rows: Record<string, any>[]): string {
+ const escape = (value: any) => {
+ if (value === null || value === undefined) return "";
+ const str = String(value).replace(/"/g, '""');
+ return `"${str}"`;
+ };
+
+ const header = columns.map(escape).join(",");
+ const lines = rows.map((row) =>
+ columns.map((col) => escape(row[col])).join(",")
+ );
+ return [header, ...lines].join("\r\n");
+}
+// ────────────────────────────────────────────────────────────────────────────────
+// Main page component
+// ────────────────────────────────────────────────────────────────────────────────
+
+export default function SqlEditorPage() {
+ const [query, setQuery] = useState<string>("");
+
+ const initialState: QueryResultState = {
+ columns: [],
+ rows: [],
+ };
+
+ // useActionState: 서버 액션과 클라이언트 상태 연결
+ const [state, formAction, isPending] = useActionState<
+ QueryResultState,
+ FormData
+ >(executeSqlAction, initialState);
+
+ // CSV 내보내기 핸들러
+ const handleExportCSV = React.useCallback(() => {
+ if (state.rows.length === 0) {
+ toast.info("내보낼 결과가 없습니다.");
+ return;
+ }
+
+ const csv = convertToCSV(state.columns, state.rows);
+ const blob = new Blob([csv], { type: "text/csv;charset=euc-kr;" });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = "query_result.csv";
+ link.click();
+ URL.revokeObjectURL(url);
+ }, [state.columns, state.rows]);
+
+ // 오류 toast 표시
+ React.useEffect(() => {
+ if (state.error) {
+ toast.error(state.error);
+ }
+ }, [state.error]);
+
+ return (
+ <div className="w-full p-4 flex flex-col h-[100dvh] gap-4">
+ {/* 상단: 쿼리 입력 영역 */}
+ <form
+ action={formAction}
+ className="flex flex-col min-h-0 space-y-4 overflow-auto"
+ >
+ <Textarea
+ name="query"
+ className="flex font-mono text-sm"
+ value={query}
+ onChange={(e) => setQuery(e.target.value)}
+ placeholder="조회가능스키마: public, mdg, nonsap, soap(로그스키마)"
+ disabled={isPending}
+ />
+ <div className="flex justify-end gap-2">
+ <p className="text-sm text-muted-foreground">조회된 행 수: {state.rows.length}</p>
+ <div className="flex gap-2">
+ <Button type="submit" disabled={isPending}>
+ {isPending ? "실행 중..." : "실행"}
+ </Button>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleExportCSV}
+ disabled={state.rows.length === 0}
+ >
+ CSV 내보내기
+ </Button>
+ </div>
+ </div>
+ </form>
+
+ {/* 하단: 결과 테이블 영역 */}
+ <div className="flex-1 overflow-auto p-4 border rounded-md">
+ {state.rows.length === 0 ? (
+ <p className="text-sm text-muted-foreground">
+ {isPending
+ ? "쿼리 실행 중"
+ : "결과 여기 표시됨"}
+ </p>
+ ) : (
+ <div className="w-full overflow-auto">
+ <table className="w-full border-collapse text-sm">
+ <thead>
+ <tr>
+ {state.columns.map((col) => (
+ <th
+ key={col}
+ className="border bg-muted px-2 py-1 text-left font-medium"
+ >
+ {col}
+ </th>
+ ))}
+ </tr>
+ </thead>
+ <tbody>
+ {state.rows.map((row, rowIdx) => (
+ <tr key={rowIdx} className="odd:bg-muted/30">
+ {state.columns.map((col) => (
+ <td key={col} className="border px-2 py-1">
+ {row[col] === null || row[col] === undefined
+ ? "NULL"
+ : String(row[col])}
+ </td>
+ ))}
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}